Etiqueta: event-subscriber

  • Implementar TOTP (MFA) en Symfony 7: guía práctica

    Implementar TOTP (MFA) en Symfony 7: guía práctica

    \n

    En esta guía práctica se explica cómo añadir autenticación multifactor TOTP (Time-based One-Time Password) a una aplicación Symfony 7. Incluye el servicio TOTP, cambios en la entidad, migración, controladores, formulario, suscriptor de eventos y recomendaciones de seguridad.

    \n\n\n\n\n\n\n\n

    Introducción

    \n\n\n\n

    TOTP es el estándar (RFC 6238) que usan Google Authenticator, Authy y similares. Genera códigos de un solo uso a partir de un secreto compartido y la hora actual.

    \n\n\n\n

    Esta implementación está pensada para integrarse con el sistema de autenticación de Symfony: genera secretos, muestra un QR para el usuario, verifica códigos en el login y obliga a verificar tras el login usando la sesión.

    \n\n\n\n

    Prerrequisitos

    \n\n\n\n
    • Proyecto Symfony 7 con sistema de autenticación ya configurado.
    • Doctrine ORM (para persistir el secreto).
    • Conocimientos básicos de seguridad en Symfony.
    \n\n\n\n

    Instalar la dependencia para generar QR (se usa Endroid en el ejemplo):

    \n\n\n\n
    composer require endroid/qr-code
    \n\n\n\n

    Desarrollo

    \n\n\n\n

    Procedimiento

    \n\n\n\n

    Resumen de pasos implementados en el proyecto:

    \n\n\n\n
    1. Agregar campos TOTP a la entidad User y crear migración.
    2. Crear un servicio TotpService con generación/verificación de códigos y generación del URI otpauth://.
    3. Crear formularios y controladores para habilitar, verificar y deshabilitar 2FA.
    4. Registrar un EventSubscriber que intercepte peticiones y redirija a la verificación cuando corresponda.
    \n\n\n\n

    A continuación se muestran fragmentos clave (no es necesario copiar todo el archivo, adapte según su proyecto).

    \n\n\n\n

    Entidad: añadir campos para estado y secreto TOTP.

    \n\n\n\n
    <?php\n// src/Entity/User.php (fragmento)\n\n#[ORM\Column(name: 'totp_enabled', type: 'boolean', options: ['default' => false])]\nprivate bool $totpEnabled = false;\n\n#[ORM\Column(name: 'totp_secret', type: 'string', length: 64, nullable: true)]\nprivate ?string $totpSecret = null;\n\npublic function isTotpEnabled(): bool\n{\n    return $this->totpEnabled;\n}\n\npublic function setTotpEnabled(bool $enabled): self\n{\n    $this->totpEnabled = $enabled;\n    return $this;\n}\n\npublic function getTotpSecret(): ?string\n{\n    return $this->totpSecret;\n}\n\npublic function setTotpSecret(?string $secret): self\n{\n    $this->totpSecret = $secret ? trim($secret) : null;\n    return $this;\n}\n
    \n\n\n\n

    Migración: ejemplo que añade las columnas totp_enabled y totp_secret.

    \n\n\n\n
    <?php\n// migrations/Version20240101000000.php (fragmento)\n\npublic function up(Schema $schema): void\n{\n    $table = $schema->getTable('users');\n    if (!$table->hasColumn('totp_enabled')) {\n        $table->addColumn('totp_enabled', 'boolean', ['default' => false]);\n    }\n    if (!$table->hasColumn('totp_secret')) {\n        $table->addColumn('totp_secret', 'string', ['length' => 64, 'notnull' => false]);\n    }\n}\n
    \n\n\n\n

    Servicio TotpService: responsabilidades principales — generar secreto, generar/verificar códigos y crear URI de aprovisionamiento.

    \n\n\n\n
    <?php\n// src/Service/TotpService.php (fragmento)\n\npublic function generateSecret(int $length = 32): string\n{\n    $bytes = random_bytes($length);\n    return rtrim(strtr(base64_encode($bytes), '+/', 'XY'), '=');\n}\n\npublic function getProvisioningUri(string $label, string $issuer, string $secret): string\n{\n    $label = rawurlencode($label);\n    $issuerEncoded = rawurlencode($issuer);\n\n    return sprintf(\n        'otpauth://totp/%s?secret=%s&issuer=%s&period=%d&digits=%d&algorithm=%s',\n        $label,\n        $secret,\n        $issuerEncoded,\n        $this->period,\n        $this->digits,\n        strtoupper($this->algorithm)\n    );\n}\n\npublic function verifyCode(string $secret, string $code, int $window = 1): bool\n{\n    $now = time();\n    $code = trim($code);\n    for ($i = -$window; $i <= $window; $i++) {\n        $timestamp = $now + ($i * $this->period);\n        if (hash_equals($this->getCode($secret, $timestamp), $code)) {\n            return true;\n        }\n    }\n    return false;\n}\n
    \n\n\n\n

    Form types: formularios sencillos para habilitar y deshabilitar 2FA que aceptan el código de 6 dígitos.

    \n\n\n\n
    <?php\n// src/Form/User/TwoFactorEnableType.php (fragmento)\n\n$builder->add('code', TextType::class, [\n    'label' => 'Authentication code',\n    'attr' => [\n        'autocomplete' => 'one-time-code',\n        'inputmode' => 'numeric',\n        'pattern' => '[0-9]*',\n        'maxlength' => 6,\n    ],\n]);\n
    \n\n\n\n

    Controlador de ajustes: genera secreto temporal en sesión, muestra QR y confirma el código antes de persistir el secreto en la base de datos.

    \n\n\n\n
    <?php\n// src/Controller/User/SettingsController.php (fragmento)\n\n$pendingSecret = $session->get('2fa_pending_secret');\n// Regenerar si no existe o expiró\nif (!$pendingSecret || (time() - $session->get('2fa_pending_secret_time', 0)) > 600) {\n    $pendingSecret = $totpService->generateSecret();\n    $session->set('2fa_pending_secret', $pendingSecret);\n    $session->set('2fa_pending_secret_time', time());\n}\n\n$uri = $totpService->getProvisioningUri($user->getEmail(), $request->getHttpHost(), $pendingSecret);\n$qrSvg = $totpService->generateInlineSvgQr($uri, 180);\n
    \n\n\n\n

    Suscriptor de eventos: intercepta solicitudes y redirige a la ruta de verificación si el usuario tiene 2FA activado pero no ha verificado la sesión.

    \n\n\n\n
    <?php\n// src/EventSubscriber/UserTwoFactorSubscriber.php (fragmento)\n\nif (!$event->isMainRequest()) {\n    return;\n}\n\n$request = $event->getRequest();\nif (!str_starts_with($request->getPathInfo(), '/panel')) {\n    return; // limitar al panel de usuario\n}\n\n$user = $this->getAuthenticatedUser();\nif (!$user || !$user->isTotpEnabled() || !$user->getTotpSecret()) {\n    return;\n}\n\n$session = $this->getSession();\nif ($session && $session->get(self::SESSION_KEY) === true) {\n    return; // ya verificado\n}\n\n$event->setResponse(new RedirectResponse($this->urlGenerator->generate('user_2fa')));\n
    \n\n\n\n

    Seguridad en la configuración del firewall: redirigir al usuario a la ruta /panel/2fa tras el login es una opción sencilla para iniciar el flujo de verificación.

    \n\n\n\n
    security:\n    firewalls:\n        user:\n            pattern: ^/panel\n            provider: user_provider\n            lazy: true\n            form_login:\n                login_path: /panel/\n                check_path: /panel/\n                enable_csrf: true\n                default_target_path: /panel/2fa\n            logout:\n                path: /panel/logout\n                target: /panel/\n\n    access_control:\n        - { path: ^/panel/?$, roles: PUBLIC_ACCESS }\n        - { path: ^/panel, roles: IS_AUTHENTICATED_REMEMBERED }\n
    \n\n\n\n

    Protecciones adicionales sugeridas: limitación de intentos (rate limiting), comparación en tiempo constante (hash_equals) y manejo cuidadoso de sesiones y secretos temporales.

    \n\n\n\n
    <?php\n// Ejemplo de rate limiting dentro del controlador de verificación\n$limiter = $twoFactorLimiter->create($request->getClientIp());\nif (!$limiter->consume()->isAccepted()) {\n    throw new TooManyRequestsHttpException(null, 'Too many attempts. Please wait.');\n}\n
    \n\n\n\n

    Ejemplos

    \n\n\n\n

    Generar un URI de aprovisionamiento (otpauth) que puedan escanear las apps autenticadoras:

    \n\n\n\n
    // Uso en controlador\n$label = $user->getEmail();\n$issuer = $request->getHttpHost();\n$secret = $totpService->generateSecret();\n$uri = $totpService->getProvisioningUri($label, $issuer, $secret);\n$qrSvg = $totpService->generateInlineSvgQr($uri, 180);\n
    \n\n\n\n

    Verificar un código recibido del usuario (ventana de tolerancia = 1 período por defecto):

    \n\n\n\n
    // Verificación\nif ($totpService->verifyCode($storedSecret, $submittedCode)) {\n    // Aceptado: marcar sesión como verificada\n    $session->set('2fa_verified', true);\n} else {\n    // Código inválido\n}\n
    \n\n\n\n

    Checklist

    \n\n\n\n
    1. Agregar totp_enabled y totp_secret en la entidad y migrar.
    2. Registrar TotpService en el contenedor y probar generación/verificación localmente.
    3. Crear formularios y controladores para setup/disable/verify.
    4. Implementar EventSubscriber para forzar verificación tras el login.
    5. Configurar rate limiting y proteger rutas sensibles.
    6. Planificar recuperación: backup codes o proceso de soporte.
    \n\n\n\n

    Conclusión

    \n\n\n\n

    Implementar TOTP sin dependencias externas ofrece control y entendimiento del flujo de MFA. El patrón mostrado (servicio TOTP + suscriptor de eventos + verificación en sesión) es ligero y fácil de adaptar a requisitos adicionales como backup codes o WebAuthn.

    \n\n\n\n

    Priorice siempre: comparación en tiempo constante, limitación de intentos y proteger los secretos en reposo. Después de implementar, pruebe el flujo completo en entornos de staging antes de desplegar en producción.

    \n\n